Pro Entity Framework Core 2 for ASP.NET Core MVC 翻译

第 15 章 处理关系-第一部分

作者:Adam Freeman
翻译:陈广
日期:2019-5-7


在本章中,我将向您展示如何直接访问关联数据,一旦完成了这些操作,您就可以完成关联,以便在关联对象之间的两个方向上执行导航。本章重点讨论一对多的关系,这些关系是最容易创建的,但在执行查询或更新数据库时需要小心。在 16 章中,我将描述 Entity Framework Core 支持的其它类型的数据关系。表 15-1 为本章摘要。

表 15-1:本章摘要

问题 解决方案 清单
直接而不是通过导航属性访问关联数据 向 context 类添加DbSet<T>,然后创建并应用迁移 1-7
按类型访问关联数据 使用DbContext.Set<T>方法 8-12
在两个方向导航关系 使用导航属性完成关系 13-15,20-33
强制执行查询 使用显式加载 16
跨多个查询组合数据关系 依赖于修复功能 17-19

准备本章

本章使用在第11章中创建并在此后的章节中修改的 DataApp 项目。为了准备本章,在 DataApp 文件夹中打开一个命令提示符并运行清单 15-1 所示的命令。

提示:如果您不想跟着构建示例项目,可以从本书的源代码存储库下载所有必需的文件,https://github.com/apress/pro-ef-core-2-for-asp.net-core-mvc

清单 15-1:重置数据库

dotnet ef database drop --force --context EFDatabaseContext
dotnet ef database update --context EFDatabaseContext

这些命令删除并重建了用于存储Product对象的数据库,这确保了您在本章示例中获得正确的结果。使用dotnet run启动应用程序,并使用浏览器导航至 http://localhost:5000。应用程序将在启动时播种数据库,您将看到如图 15-1 所示的产品列表。

图15-1 运行示例应用程序

直接访问关联数据

在大多数应用程序中,至少有一些关联数据有自己的生命周期和用户需要执行的工作流。例如,在 DataApp 应用程序中,可以很容易地想象管理员可能需要进行更改以反映地址更改。这很困难,因为我只能从Product对象开始,并遵循它的导航属性来访问数据库中的数据。例如,如果我想更新ContactLocation对象,必须首先搜索与我想要修改的供应商关联的产品,查询该产品,然后按照导航属性获得SupplierContactDetails对象。只有这样,我才能访问我想要修改的ConactLocation对象。

为了避免此类问题,您可以直接访问关联数据。在接下来的部分中,我将向您展示两种不同的访问关联数据的方法,并解释何时应该使用每一种方法。

提升关联数据

对于在应用程序中起重要作用的数据,例如,它有自己的管理工具和生命周期,最好的方法是提升数据,以便可以通过 context 类定义的DbSet<T>属性访问它。

这是最具破坏性的方法,因为它需要创建和应用一个新的迁移,但它将关联数据放在应用程序的一流基础上,并遵循许多开发人员习惯的约定。

为提升Supplier数据以便可以直接访问它,我向EFDatabaseContext类添加了一个属性,如清单 15-2 所示。

清单 15-2:Models 文件夹下的 EFDatabaseContext.cs 文件,提升关联数据

using Microsoft.EntityFrameworkCore;

namespace DataApp.Models
{
    public class EFDatabaseContext : DbContext
    {
        public EFDatabaseContext(DbContextOptions<EFDatabaseContext> opts)
            : base(opts) { }

        public DbSet<Product> Products { get; set; }
        public DbSet<Supplier> Suppliers { get; set; }
    }
}

新属性返回一个DbSet<Supplier>对象,该对象可用于查询和操作数据库中的Supplier对象。在定义DbSet<T>属性时,需要进行新的迁移,因此在 DataApp 项目文件夹中运行清单 15-3 所示的命令来创建和应用迁移。

清单 15-3:创建和应用迁移

dotnet ef migrations add PromoteSuppliers --context EFDatabaseContext
dotnet ef database update --context EFDatabaseContext

如果您检查在 Migrations 文件夹中创建的<timestamp>_PromoteSuppliers.cs文件中的Up方法,您将看到,提升Supplier数据导致了包含被重命名的关联数据的数据库表,如下所示:

...
migrationBuilder.RenameTable(name: "Supplier", newName: "Suppliers");
...

名称更改是由从一个 Entity Framework Core 约定切换到另一个引起的。在清单15-3中,我遵循了为属性使用类名复数形式的惯例 —— Suppliers,本例中,Entity Framework Core 使用它在数据库中存储Supplier对象的表的名称。但是,数据库中已经有一个 Supplier 表,它是使用第 14 章中定义的导航属性名称的约定创建的。DbSet<T>属性的约定优先,并导致包含要重命名的Supplier对象的表。

使用提升数据

一旦您提升了数据,您就可以使用前面章节中描述的技术来访问它。为了给Supplier对象提供一个存储库,我在 Models 文件夹中添加了一个名为 SupplierRepository.cs 的类文件,并定义了如清单 15-4 所示的接口和类。

清单 15-4:Models 文件夹下的 SupplierRepository.cs 文件的内容

using System.Collections.Generic;

namespace DataApp.Models
{
    public interface ISupplierRepository
    {
        Supplier Get(long id);
        IEnumerable<Supplier> GetAll();
        void Create(Supplier newDataObject);
        void Update(Supplier changedDataObject);
        void Delete(long id);
    }

    public class SupplierRepository : ISupplierRepository
    {
        private EFDatabaseContext context;
        public SupplierRepository(EFDatabaseContext ctx) => context = ctx;
        public Supplier Get(long id)
        {
            return context.Suppliers.Find(id);
        }
        public IEnumerable<Supplier> GetAll()
        {
            return context.Suppliers;
        }
        public void Create(Supplier newDataObject)
        {
            context.Add(newDataObject);
            context.SaveChanges();
        }
        public void Update(Supplier changedDataObject)
        {
            context.Update(changedDataObject);
            context.SaveChanges();
        }
        public void Delete(long id)
        {
            context.Remove(Get(id));
            context.SaveChanges();
        }
    }
}

我在这个存储库中使用了简单的操作,避免了第 12 章中描述的更复杂的优化。在清单 15-5 中,我在Startup类中注册了存储库,以便在应用程序的其余部分中将其作为服务使用。

清单 15-5:DataApp 文件夹下的 Startup.cs 文件,注册提升存储库

...
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
    string conString = Configuration["ConnectionStrings:DefaultConnection"];
    services.AddDbContext<EFDatabaseContext>(options =>
        options.UseSqlServer(conString));

    string customerConString =
        Configuration["ConnectionStrings:CustomerConnection"];
    services.AddDbContext<EFCustomerContext>(options =>
        options.UseSqlServer(customerConString));

    services.AddTransient<IDataRepository, EFDataRepository>();
    services.AddTransient<ICustomerRepository, EFCustomerRepository>();
    services.AddTransient<MigrationsManager>();
    services.AddTransient<ISupplierRepository, SupplierRepository>();
}
...

为给供应商数据提供一个控制器,我在 Controllers 文件夹中添加了一个名为 RelatedDataController.cs 的类文件,并定义了清单 15-6 所示的类。

清单 15-6:Controllers 文件夹下的 RelatedDataController.cs 文件的内容

using DataApp.Models;
using Microsoft.AspNetCore.Mvc;

namespace DataApp.Controllers
{
    public class RelatedDataController : Controller
    {
        private ISupplierRepository supplierRepo;
        public RelatedDataController(ISupplierRepository repo)
            => supplierRepo = repo;
        public IActionResult Index() => View(supplierRepo.GetAll());
    }
}

控制器定义了单个 action 方法,用于在存储库中查询数据库中的所有Supplier对象,并将其传递给默认视图。我创建了 Views/RelatedData 文件夹并向其中添加了名为 Index.cshtml 的视图,内容如清单 15-7 所示。

清单 15-7: Views/RelatedData 文件夹下的 Index.cshtml 文件的内容

@model IEnumerable<DataApp.Models.Supplier>

@{
    ViewData["Title"] = "Suppliers";
    Layout = "_Layout";
}

<table class="table table-striped table-sm">
    <tr><th>ID</th><th>Name</th><th>City</th><th>State</th></tr>
    @foreach (var s in Model.OrderBy(s => s.Id))
    {
        <tr>
            <td>@s.Id</td>
            <td>@s.Name</td>
            <td>@s.City</td>
            <td>@s.State</td>
        </tr>
    }
</table>

要查看 Supplier 数据提升后的效果,启动应用程序,并导航至 http://localhost:5000/relateddata 以查看结果,如图 15-2 所示。

图15-2 提升关联数据

使用类型参数访问关联数据

提升数据的替代方法是使用数据库 context 类提供的一组方法,这些方法允许将数据类型指定为类型参数。这对于处理您需要偶尔或有限访问特定操作的数据来说,是有用的功能,并且不需要提升至DbSet<T>属性。表 15-2 描述了接受类型参数并可用于访问数据而无需提升的DbContext方法。

表 15-2:带有类型参数的 DbContext 方法

名称 描述
Set() 此方法返回一个可用于查询数据库的DbSet<T>对象
Find(key) 此方法在数据库中查询具有指定键的T类型对象。
Add(newObject) 此方法在数据库中添加一个类型为T的新对象
Update(changedObject) 此方法更新类型为T的对象
Remove(dataObject) 此方法从数据库中删除一个类型为T的对象

这些方法的一个优点是,它们可以用于创建一个通用存储库,在将其配置为Startup类中的服务时,该存储库可用于提供对特定类型的访问。我在 Models 文件夹中添加了一个名为 GenericRepository.cs 的类文件,并定义了如清单 15-8 所示的接口和类。

清单 15-8:Models 文件夹下的 GenericRepository.cs 文件的内容

using System.Collections.Generic;

namespace DataApp.Models
{
    public interface IGenericRepository<T> where T : class
    {
        T Get(long id);
        IEnumerable<T> GetAll();
        void Create(T newDataObject);
        void Update(T changedDataObject);
        void Delete(long id);
    }

    public class GenericRepository<T> : IGenericRepository<T> where T : class
    {
        protected EFDatabaseContext context;
        public GenericRepository(EFDatabaseContext ctx) => context = ctx;
        public virtual T Get(long id)
        {
            return context.Set<T>().Find(id);
        }
        public virtual IEnumerable<T> GetAll()
        {
            return context.Set<T>();
        }
        public virtual void Create(T newDataObject)
        {
            context.Add<T>(newDataObject);
            context.SaveChanges();
        }
        public virtual void Delete(long id)
        {
            context.Remove<T>(Get(id));
            context.SaveChanges();
        }
        public virtual void Update(T changedDataObject)
        {
            context.Update<T>(changedDataObject);
            context.SaveChanges();
        }
    }
}

IGenericRepository<T>接口定义了存储库必须提供的用于类型T的操作。where子句将类型参数限制为类,这是 Entity Framework Core 施加的约束。GenericRepository<T>类使用与Supplier类相同的基本技术实现接口,但使用表15-2中描述的方法执行。

提示:清单15-8中的方法被标记为virtual,这样我就可以创建存储库类的更为专用的实现,而不必对应用程序进行大面积的更改。

接口和实现类将使用的特定类型是在Startup类中创建依赖注入服务时配置的。在清单15-9中,我定义了ContactDetailsContactLocation类的服务。

清单 15-9:DataApp 文件夹下的 Startup 类,创建服务

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
    string conString = Configuration["ConnectionStrings:DefaultConnection"];
    services.AddDbContext<EFDatabaseContext>(options =>
        options.UseSqlServer(conString));

    string customerConString =
        Configuration["ConnectionStrings:CustomerConnection"];
    services.AddDbContext<EFCustomerContext>(options =>
        options.UseSqlServer(customerConString));

    services.AddTransient<IDataRepository, EFDataRepository>();
    services.AddTransient<ICustomerRepository, EFCustomerRepository>();
    services.AddTransient<MigrationsManager>();
    services.AddTransient<ISupplierRepository, SupplierRepository>();
    services.AddTransient<IGenericRepository<ContactDetails>,
        GenericRepository<ContactDetails>>();
    services.AddTransient<IGenericRepository<ContactLocation>,
        GenericRepository<ContactLocation>>();
}

为显示通过泛型 context 方法获得的数据,我向RelatedData控制器添加了 action 方法,如清单15-10所示。这些方法查询每个类型的所有可用对象,尽管可以使用所有标准数据操作。

清单 15-10:Controllers 文件夹下的 RelatedDataController.cs 文件,查询数据

using DataApp.Models;
using Microsoft.AspNetCore.Mvc;

namespace DataApp.Controllers
{
    public class RelatedDataController : Controller
    {
        private ISupplierRepository supplierRepo;
        private IGenericRepository<ContactDetails> detailsRepo;
        private IGenericRepository<ContactLocation> locsRepo;

        public RelatedDataController(ISupplierRepository sRepo,
            IGenericRepository<ContactDetails> dRepo,
            IGenericRepository<ContactLocation> lRepo)
        {
            supplierRepo = sRepo;
            detailsRepo = dRepo;
            locsRepo = lRepo;
        }

        public IActionResult Index() => View(supplierRepo.GetAll());
        public IActionResult Contacts() => View(detailsRepo.GetAll());
        public IActionResult Locations() => View(locsRepo.GetAll());
    }
}

警告:您可能会尝试以使用类型参数访问数据类的想法为基础,并在 ASP.NET Core 管道中设置一个不需要每个类的 action 方法和视图的通用处理程序和控制器。这似乎是个好主意,但应该避免,因为它提供了对数据库中所有数据的访问,这不是一个好主意。在幕后使用类型参数可以使应用程序保持简单,但可以单独显式地授予对类的访问权限。

泛型存储库以正常的方式通过构造函数接收,并由ContactsLocations action 方法使用,这两个方法都使用默认视图。为显示ContactDetails对象,我在 Views/RelatedData 文件夹下创建了一个名为 Contacts.cshtml 的文件,内容为清单15-11所示。

清单 15-11:Views/RelatedData 文件夹下的 Contacts.cshtml 文件的内容

@model IEnumerable<DataApp.Models.ContactDetails>
@{
    ViewData["Title"] = "ContactDetails";
    Layout = "_Layout";
}
<table class="table table-striped table-sm">
    <tr><th>ID</th><th>Name</th><th>Phone</th></tr>
    @foreach (var s in Model)
    {
        <tr><td>@s.Id</td><td>@s.Name</td><td>@s.Phone</td></tr>
    }
</table>

该视图在表格中显示ContactDetails属性。为给ContactLocation对象提供相应的视图,我将一个名为 Locations.cshtml 的文件添加到 Views/RelatedData 文件夹中,内容如清单15-12所示。

清单 15-12:Views/RelatedData 文件夹下的 Locations.cshtml 文件的内容

@model IEnumerable<DataApp.Models.ContactLocation>
@{
    ViewData["Title"] = "ContactLocations";
    Layout = "_Layout";
}
<table class="table table-striped table-sm">
    <tr><th>ID</th><th>Name</th></tr>
    @foreach (var s in Model)
    {
        <tr><td>@s.Id</td><td>@s.LocationName</td></tr>
    }
</table>

要通过泛型存储库访问关联数据,重启应用程序,并导航至 http://localhost:5000/relateddata/contacts 以及 http://localhost:5000/relateddata/locations,它们所针对的 action 方法定义于清单 15-10 中,产生的结果如图15-3所示。

图15-3 通过通用存储库访问关联数据

完全数据关系

提升关联数据使得它更易于访问,但使用它还可以进一步改进。目前,类之间的关系只向一个方向流动。例如,我可以从一个Product对象开始,然后跟随导航属性获取相关的Supplier数据,但是我不能从一个Supplier开始,然后向相反的方向导航。为了解决这个问题,Entity Framework Core 允许我定义在另一个方向导航的属性,即完全关系( completing the relationship)

可以在类之间创建一系列不同类型的关系,但当您首次定义导航属性时,Entity Framework Core 假设您希望创建一对多的关系,并且导航属性会在关系的“多”这一端添加到类中。这就是我在第 14 章中向Product类添加了一个导航属性时发生的情况:Entity Framework Core 创建了一对多的关系,允许每个Supplier对象与许多Product对象关联,并相应地配置数据库。

提示:我在第 16 章描述了如何创建其它类型的关系。

完全一对多的关系很容易,需要在关系的“一”端将导航属性添加到类中。这被称为*逆(inverse)*属性,为了完成ProductSupplier类之间的关系,我将清单15-13所示的逆导航属性添加到Supplier类中。

清单 15-13:Models 文件夹下的 Supplier.cs 文件,完全关系

using System.Collections.Generic;

namespace DataApp.Models
{
    public class Supplier
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public string City { get; set; }
        public string State { get; set; }
        public ContactDetails Contact { get; set; }
        public IEnumerable<Product> Products { get; set; }
    }
}

导航属性返回关系中的另一个类的枚举,本例为Product。这反映了这样一个事实:每个Supplier对象都可以与许多Product对象关联,并且使用IEnumerable<Product>使得 Entity Framework Core 可以提供完整的关联数据集。

注意:当你完全一对多的关系时,不需要创建一个新的迁移。如第 16 章所示,其他类型的关系需要新的迁移。

在一对多的关系中查询关联数据

一旦完全了关系,您可以从关系中的任一类型的对象开始,然后在两个方向导航。在示例应用程序中,这意味着只要关联数据包含在请求中,我就可以从Supplier对象开始,循着Products属性导航到关联的Product对象集。将关联数据包含在已完全的一对多关系中,可使用多种不同的方式执行,我在下面的部分中将对此进行解释。

查询所有关联数据

如果您想要完整的关联数据,可以通过选择导航属性,使用IncludeThenInclude方法扩展查询。在清单15-14中,我使用了Include方法来跟随清单15-13中定义的Supplier.Products属性,以查询与每个Supplier关联的所有Product对象。

清单 15-14:Models 文件夹下的 SupplierRepository.cs 文件,查询所有数据

using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using System.Linq;

namespace DataApp.Models
{
    public interface ISupplierRepository
    {
        Supplier Get(long id);
        IEnumerable<Supplier> GetAll();
        void Create(Supplier newDataObject);
        void Update(Supplier changedDataObject);
        void Delete(long id);
    }

    public class SupplierRepository : ISupplierRepository
    {
        private EFDatabaseContext context;
        public SupplierRepository(EFDatabaseContext ctx) => context = ctx;
        public Supplier Get(long id)
        {
            return context.Suppliers.Find(id);
        }
        public IEnumerable<Supplier> GetAll()
        {
            return context.Suppliers.Include(s => s.Products);
        }
        public void Create(Supplier newDataObject)
        {
            context.Add(newDataObject);
            context.SaveChanges();
        }
        public void Update(Supplier changedDataObject)
        {
            context.Update(changedDataObject);
            context.SaveChanges();
        }
        public void Delete(long id)
        {
            context.Remove(Get(id));
            context.SaveChanges();
        }
    }
}

存储库中的更改告诉 Entity Framework Core 查询所有与某个Supplier对象关联的所有Product对象。需要进行其他更改,以便向用户显示关联数据,并开始构建数据操作集。为了显示与Supplier相关的Product对象,我将清单15-15所示的内容添加到 Views/RelatedData 文件夹中的 Index.cshtml 文件中。

清单 15-15:Views/RelatedData 文件夹下的 Index.cshtml 文件,显示关联数据**

@model IEnumerable<DataApp.Models.Supplier>

@{
    ViewData["Title"] = "Suppliers";
    Layout = "_Layout";
}

<table class="table table-striped table-sm">
    <tr><th>ID</th><th>Name</th><th>City</th><th>State</th></tr>
    @foreach (var s in Model.OrderBy(s => s.Id))
    {
        <tr>
            <td>@s.Id</td>
            <td>@s.Name</td>
            <td>@s.City</td>
            <td>@s.State</td>
        </tr>
        @if (s.Products != null)
        {
            @foreach (var p in s.Products)
            {
                <tr class="table-dark">
                    <td></td>
                    <td>@p.Name</td>
                    <td>@p.Category</td>
                    <td>@p.Price</td>
                </tr>
            }
        }
    }
</table>

视图更改为在供应商对象后面显示了与之关联的每个产品对象的摘要。要查看此效果,启动应用程序,并导航至 http://localhost:5000/relateddata。产品对象显示为深色行,如图15-4所示。

图15-4 在一对多关系中查询所有关联数据

当考虑到在一对多的关系中获取关联数据的不同方式时,检查用于从数据库获取数据的查询是很有帮助的。如果检查应用程序生成的日志输出,您将看到首先请求使用供应商数据,如下所示:

...
SELECT [s].[Id], [s].[City], [s].[ContactId], [s].[Name], [s].[State]
FROM [Suppliers] AS [s]
ORDER BY [s].[Id]
...

要获取关联数据,发送了第二个查询,它获取了关联的新产品数据,如下:

...
SELECT [s.Products].[Id], [s.Products].[Category], [s.Products].[Color],
    [s.Products].[InStock], [s.Products].[Name], [s.Products].[Price],
    [s.Products].[SupplierId]
FROM [Products] AS [s.Products]
INNER JOIN (
    SELECT [s0].[Id]
    FROM [Suppliers] AS [s0]
) AS [t] ON [s.Products].[SupplierId] = [t].[Id]
ORDER BY [t].[Id]
...

使用显式加载查询

在一对多导航属性之后使用Include方法并不提供任何过滤关联数据的途径,这意味着从数据库获取的是所有东西。如果您想更有选择性的话,Entity Framework Core 提供了两种途径对查询进行过滤。第一种是显式加载(explicit loading),我在清单15-16中使用它来代替了Include方法。

清单 15-16:Models 文件夹下的 SupplierRepository.cs 文件,使用显式加载

...
public IEnumerable<Supplier> GetAll()
{
    IEnumerable<Supplier> data = context.Suppliers.ToArray();
    foreach (Supplier s in data)
    {
        context.Entry(s).Collection(e => e.Products)
            .Query()
            .Where(p => p.Price > 50)
            .Load();
    }
    return data;
}
...

显式加载依赖于DbContext.Entry方法,我在第12章使用它来访问跟踪数据的更改。调用 context 对象定义的Entry方法将返回EntityEntry对象,该对象提供了访问关联数据的两种方法,如表15-3所述。

表 15-3:用于关联数据的 EntityEntry 方法

名称 描述
Reference(name) 此方法用于指向单个对象的导航属性,该对象指定为字符串或使用 lambda 表达式选择属性。
Collection(name) 此方法用于指向集合的导航属性,该集合指定为字符串或使用 lambda 表达式选择属性。

一旦您使用了ReferenceCollection方法选择导航属性,Query方法用于获取一个IQueryable对象,该对象可与 LINQ 一起用于筛选将要加载的数据。在清单15-16中,Where方法用于筛选基于价格的关联数据,告诉 Entity Framework Core 仅查询Price值大于 50 的关联Product对象。

Load方法用于强制执行查询。这通常并不是必需的,因为当IQueryable<T>被 Razor 视图或 LINQ 方法枚举时,查询将自动执行。但是,在这种情况下,需要Load方法,因为Query方法返回的IQueryable<T>对象从未被枚举。没有Load方法,关联数据的查询将不被执行,仅有Supplier对象将被传递给 Razor 视图(Load方法拥有与ToArrayToList相同的效果,但不会创建或返回对象集合)。

注意:清单15-6中,我在foreach循环中枚举Supplier对象之前,使用了ToArray方法。ToArray方法强制执行获取Supplier数据的查询,避免了在存储库方法中的foreach循环枚举IQueryable<Supplier>时执行一个查询,而在 Razor 视图中的foreach循环触发重复查询。

要查看显式加载的效果,重启应用程序,并导航至 http://localhost:5000/relateddata。这一次仅显示了过滤后的Product对象,如图15-5所示。

图15-5 使用显式加载筛选数据

使用显式加载的缺点是它为数据库生成许多查询。如果检查应用程序创建的日志消息,您将看到第一个查询是针对供应商数据的,如下所示:

...
SELECT [s].[Id], [s].[City], [s].[ContactId], [s].[Name], [s].[State]
FROM [Suppliers] AS [s]
...

当每个Supplier对象都由foreach方法处理并显式加载关联数据时,将向数据库发送另一个查询,如下所示:

...
SELECT [e].[Id], [e].[Category], [e].[Color], [e].[InStock], [e].[Name],
    [e].[Price], [e].[SupplierId]
FROM [Products] AS [e]
WHERE ([e].[SupplierId] = @__get_Item_0) AND ([e].[Price] > 50.0)
...

数据库中存储着三个Supplier对象,这意味着总共需要四个请求才能获得这个示例的数据。当存在大量的对象时查询数量会大大增加,这意味着当有少量需要相关数据的对象时,显式加载技术是最有效的。如果您正在处理更多的对象,那么下一节中的技术可能更适合。

使用修复功能进行查询

Entity Framework Core 支持一个叫*修复(fixing up)*的功能,它缓存数据库 context 对象检索的数据,并用于填充为后续查询创建的对象的导航属性。这是需要谨慎使用功能,它允许创建复杂的查询,这些查询比显式加载或使用Include方法跟踪导航属性更有效地获取关联数据。在清单15-17中,我依赖于修复功能来获得Supplier对象和Price值超过 50 的关联Product对象。

清单 15-17:Models 文件夹下的 SupplierRepository.cs 文件,修复功能

...
public IEnumerable<Supplier> GetAll()
{
    context.Products.Where(p => p.Supplier != null && p.Price > 50).Load();
    return context.Suppliers;
}
...

此例存在两个查询。第一个查询使用 context 对象的Products属性来获取所有与Supplier有关联并且Price属性大于 50 的Product对象。此查询的唯一目的是用数据对象填充 Entity Framework Core 缓存,因此我使用Load方法强制查询的计算。

第二个查询使用 context 对象的Suppliers属性来获取Supplier对象。当在 Razor 视图中枚举对象序列时,将执行此查询,因此不需要Load方法。执行第二个查询时,Entity Framework Core 将自动检查从第一个请求中缓存的数据,并使用该数据向Supplier.Products导航属性填充适当的Product对象。结果与我使用显式加载时的结果相同,如图15-6所示。

图15-6 使用修复功能筛选数据

警告:使用修复功能可能需要一些尝试和错误,并且需要仔细注意发送到数据库的查询。可以使用比显式加载技术更少的查询来获取所需的所有数据,但令人惊讶的是,它很容易遗漏标记,或者查询太多或太少的数据,或者生成比您预期的更多的请求。

如果查找应用程序产生的日志信息,您将看到发送给数据库的查询。第一条查询检索符合Where方法指定条件的Product对象。

...
SELECT [p].[Id], [p].[Category], [p].[Color], [p].[InStock], [p].[Name],
    [p].[Price], [p].[SupplierId]
FROM [Products] AS [p]
WHERE [p].[SupplierId] IS NOT NULL AND ([p].[Price] > 50.0)
...

第二条查询检索所有可用的Supplier数据。

...
SELECT [s].[Id], [s].[City], [s].[ContactId], [s].[Name], [s].[State]
FROM [Suppliers] AS [s]
...

修复过程使用来自第一个查询的数据填充从第二个查询创建的对象的导航属性。此过程是完全自动的,每当使用相同的数据库 context 对象进行多个查询时,都会执行该过程(这也是为什么您应该注意在Startup类中配置的存储库对象的生命周期的原因之一,这样您就不会在没有预期的情况下得到修复的数据。)

理解修复陷阱

修复功能意味着 Entity Framework Core 会将之前创建的对象填充至导航属性。如果仔细使用,修复过程是一个强大的工具,用于选择所需的数据,而无需不断地查询数据库。但是,修复过程无法禁用,如果您不考虑所做的查询,则跟踪导航属性会给粗心大意的人带来一个陷阱。

为了演示这个问题,我在 Views/Home 文件夹中创建了一个名为 SupplierRelated.cshtml 的视图,并添加了清单15-18中所示的内容,以向用户展示Supplier及其Product对象列表。

清单 15-18:Views/Home 文件夹下的 SupplierRelated.cshtml 文件的内容

@model DataApp.Models.Supplier

@if (Model?.Products == null)
{
    <tr><td colspan="6" class="text-center table-dark">No Related Data</td></tr>
}
else
{
    @foreach (Product p in Model?.Products)
    {
        <tr class="table-dark">
            <td colspan="3"></td>
            <td>@p.Name</td>
            <td>@p.Category</td>
            <td>@p.Price</td>
        </tr>
    }
}

此视图接收Supplier作为其模型,并跟随Products导航属性创建关联产品的简单表格。为了使用这个视图,我对 Views/Home 文件夹中的 Index.cshtml 视图进行了清单15-19所示的更改。

清单 15-19:Views/Home 文件夹下的 Index.cshtml 文件,显示关联数据

...
<tbody>
    @foreach (var p in Model)
    {
        <tr>
            <td>@p.Id</td>
            <td>@p.Name</td>
            <td>@p.Category</td>
            <td>$@p.Price.ToString("F2")</td>
            @if (ViewBag.includeRelated)
            {
                <td>@p.Supplier?.Name</td>
            }
            <td>
                <form asp-action="Delete" method="post">
                    <a asp-action="Edit" class="btn btn-sm btn-warning"
                        asp-route-id="@p.Id">
                        Edit
                    </a>
                    <input type="hidden" name="id" value="@p.Id" />
                    <button type="submit" class="btn btn-danger btn-sm">
                        Delete
                    </button>
                </form>
            </td>
        </tr>
        <tr>
            <td colspan="6">@await Html.PartialAsync("SupplierRelated", p.Supplier)</td>
        </tr>
    }
</tbody>
...

要查看问题,请重新启动应用程序并导航到 http://localhost:5000。修复过程用于填充导航属性作为 Entity Framework Core 处理来自数据库服务器的响应,这意味着数据不一致,如图15-7所示。

图15-7 导航到查询范围以外的数据

图中显示了Soccer类别中的产品,您可以看到每个项的关联数据是不同的。这是因为我跟踪的导航属性超出了查询中指定的关联数据,首先是Product对象,然后转移到关联的Supplier,然后再回到关联的Products。 Entity Framework Core 使用修复数据填充Supplier.Products属性,但只有到目前为止创建的Product对象才可以使用,它会随着处理的每个Product对象而增加,并产生图中所示的不一致结果。 若要避免这种类型的问题,请不要使用尚未使用IncludeThenInclude方法选择的属性导航,也不要使用以前的查询进行修复。

在一对多的关系中处理关联数据

完全关系最重要的特征之一是可以使用关系两端的导航属性来执行操作。在下面的部分中,我将向您展示如何使用SupplierProduct类之间一对多的关系中的导航属性来执行不同的操作。

为了准备这一节,我更改了SupplierRepository类中用于Supplier对象的查询,以便包含所有关联的Product对象,如清单15-20所示。

清单 15-20:Models 文件夹下的 SupplierRepository.cs 文件,更改查询

...
public IEnumerable<Supplier> GetAll()
{
    return context.Suppliers.Include(p => p.Products);
}
...

为了将此示例与本章中的其他示例分开,我在控制器文件夹中添加了一个名为 SuppliersController.cs 的类文件,并定义了如清单15-21所示的控制器。

清单 15-21:Controllers 文件夹下的 SuppliersController.cs 文件的内容

using DataApp.Models;
using Microsoft.AspNetCore.Mvc;

namespace DataApp.Controllers
{
    public class SuppliersController : Controller
    {
        private ISupplierRepository supplierRepository;
        public SuppliersController(ISupplierRepository supplierRepo)
        {
            supplierRepository = supplierRepo;
        }
        public IActionResult Index()
        {
            return View(supplierRepository.GetAll());
        }
    }
}

控制器定义了将所有Supplier对象传递给默认视图的单个 action 方法。为了显示Supplier对象及其相关的Product对象,我创建了 Views/Suppliers 文件夹,并在其中添加了一个名为 Index.cshtml 的文件,内容如清单15-22所示。

清单 15-22:Views/Suppliers 文件夹下的 Index.cshtml 文件的内容

@model IEnumerable<DataApp.Models.Supplier>

@{
    ViewData["Title"] = "Suppliers";
    Layout = "_Layout";
}

@foreach (Supplier s in Model)
{
    <h4 class="bg-info text-center text-white p-1">@s.Name</h4>
    <div class="container-fluid">
        @if (s.Products == null || s.Products.Count() == 0)
        {
            <div class="p-1 text-center">No Products</div>
        }
        else
        {
            @foreach (Product p in s.Products)
            {
                <div class="row p-1">
                    <div class="col">@p.Name</div>
                    <div class="col">@p.Category</div>
                    <div class="col">@p.Price</div>
                </div>
            }
        }
    </div>
}

为查看视图所生成的内容,重启应用程序,并导航至 http://localhost:5000/suppliers。显示每个供应商的名称,以及相关产品对象的详细信息,如图15-8所示。

图15-8 显示供应商和产品对象

更新关联对象

在本章的前面,我向您展示了如何编辑直接使用数据库 context 类定义的DbSet<Product>属性从数据库检索的Product对象的值。这是执行更新的最简单和最直接的方法,但您也可以从Supplier对象开始进行更改,并通过导航属性访问要修改的Product对象。为了演示如何通过导航属性执行编辑,我首先在 Views/Suppliers 文件夹中创建一个名为 Editor.cshtml 的视图,并添加清单15-23所示的内容。

清单 15-23:Views/Suppliers 文件夹下的 Editor.cshtml 文件的内容

@model Supplier
@{
    int counter = 0;
}

<form asp-action="Update" method="post">
    <input type="hidden" asp-for="Id" />
    <input type="hidden" asp-for="Name" />
    <input type="hidden" asp-for="City" />
    <input type="hidden" asp-for="State" />
    @foreach (Product p in Model.Products)
    {
        <div class="row">
            <input type="hidden" name="Products[@counter].Id" value="@p.Id" />
            <div class="col">
                <input name="Products[@counter].Name" value="@p.Name"
                       class="form-control" />
            </div>
            <div class="col">
                <input name="Products[@counter].Category" value="@p.Category"
                       class="form-control" />
            </div>
            <div class="col">
                <input name="Products[@counter].Price" value="@p.Price"
                       class="form-control" />
            </div>
            @{ counter++; }
        </div>
    }
    <div class="row">
        <div class="col text-center m-1">
            <button class="btn btn-sm btn-danger" type="submit">Save</button>
            <a class="btn btn-sm btn-secondary" asp-action="Index">Cancel</a>
        </div>
    </div>
</form>

此视图将用于允许用户编辑与Supplier相关的所有Product对象的属性值。稍微有些尴尬的结构依赖于一个名为counterint自增值,它用于创建 HTML 元素,这些元素将被 MVC 模型绑定器正确地解析为Product对象数组。属性值显示在input元素的网格中,当用户单击【Save】按钮时,这些属性值将被提交到名为Update的 action 中。

为了将 Editor 视图合并到应用程序中,我将清单15-24所示的元素添加到 Views/Suppliers 文件夹中的 Index.cshtml 视图中。

清单 15-24:Views/Suppliers 文件夹下的 Index.cshtml 文件,合并 Editor

@model IEnumerable<DataApp.Models.Supplier>
@{
    ViewData["Title"] = "Suppliers";
    Layout = "_Layout";
}

@foreach (Supplier s in Model)
{
    <h4 class="bg-info text-center text-white p-1">
        @*第一处添加*@
        @s.Name
        <a asp-action="Edit" asp-route-id="@s.Id"
           class="btn btn-sm btn-warning">
            Edit
        </a>
    </h4>
    <div class="container-fluid">
        @if (s.Products == null || s.Products.Count() == 0)
        {
            <div class="p-1 text-center">No Products</div>
        }
        @*第二处添加*@
        else if (ViewBag.SupplierEditId == s.Id)
        {
            @await Html.PartialAsync("Editor", s);
        }
        else
        {
            @foreach (Product p in s.Products)
            {
                <div class="row p-1">
                    <div class="col">@p.Name</div>
                    <div class="col">@p.Category</div>
                    <div class="col">@p.Price</div>
                </div>
            }
        }
    </div>
}

第一处添加在每个供应商的名称旁边添加一个【Edit】按钮,该按钮以Edit action 为目标并包含Supplier对象的Id。这将通过在ViewBag中设置一个SupplierEditId属性来启动编辑过程,清单15-24中的第二处添加使用该属性来显示 Editor 视图。

为了提供视图所需的支持,我将清单15-25所示的 action 方法添加到Suppliers控制器中。

清单 15-25:Controllers 文件夹下的 SuppliersController.cs 文件,添加 action

using DataApp.Models;
using Microsoft.AspNetCore.Mvc;
namespace DataApp.Controllers
{
    public class SuppliersController : Controller
    {
        private ISupplierRepository supplierRepository;

        public SuppliersController(ISupplierRepository supplierRepo)
        {
            supplierRepository = supplierRepo;
        }

        public IActionResult Index()
        {
            ViewBag.SupplierEditId = TempData["SupplierEditId"];
            return View(supplierRepository.GetAll());
        }

        public IActionResult Edit(long id)
        {
            TempData["SupplierEditId"] = id;
            return RedirectToAction(nameof(Index));
        }

        [HttpPost]
        public IActionResult Update(Supplier supplier)
        {
            supplierRepository.Update(supplier);
            return RedirectToAction(nameof(Index));
        }
    }
}

要查看编辑过程如何工作,请重新启动应用程序,导航到 http://localhost:5000/suppliers,并单击“Chess Kings”的【Edit】按钮。使用input元素进行更改,并单击【Save】按钮更新数据库,如图15-9所示。

图15-9 通过导航属性编辑属性值

当您单击【Save】按钮,浏览器将发送一个 HTTP 请求,此请求包含了 MVC 模型绑定器要创建一个Supplier对象以及Product对象集合所需的值。MVC 绑定器自动将 Product 集合赋予将用于更新数据库的Supplier.Products属性。Entity Framework Core 无法对这些对象执行更改跟踪(因为它们是由 MVC 模型绑定器创建的),因此Supplier对象必须包含其所有属性的值,您可以在 Editor 视图中看到这一点。

注意:请记住,完全一段关系并不会阻止您直接操作对象。例如,完全 Product/Supplier 关系并不意味着我只能通过Supplier.Products导航属性更新Product对象;我仍然可以直接查询Product对象并单独更新它们。完全一段关系可以开辟新的工作方式,让你选择最适合项目的操作。

创建新的关联对象

我在上一节中描述的技术可以很容易地用于创建新的关联对象。当 Entity Framework Core 处理 MVC 模型绑定器创建的Product对象集合时,任何Id属性值为 0 的Product对象都将作为新对象添加到数据库中。这是一种创建自动与供应商关联的新对象的方便方法。这是一种在不打破产品和供应商对象之间所需关系约束的情况下创建新对象的有用方法。

为了增加对创建新Product对象的支持,我在 Views/Suppliers 文件夹中添加了一个名为 Create.cshtml 的视图,并添加了清单15-26所示的内容。

清单 15-26:Views/Suppliers 文件夹下的 Create.cshtml 文件的内容

@model Supplier
@{
    int counter = 0;
}

<form asp-action="Update" method="post">
    <input type="hidden" asp-for="Id" />
    <input type="hidden" asp-for="Name" />
    <input type="hidden" asp-for="City" />
    <input type="hidden" asp-for="State" />
    @foreach (Product p in Model.Products)
    {
        <input type="hidden" name="Products[@counter].Id" value="@p.Id" />
        <input type="hidden" name="Products[@counter].Name" value="@p.Name" />
        <input type="hidden" name="Products[@counter].Category"
               value="@p.Category" />
        <input type="hidden" name="Products[@counter].Price" value="@p.Price" />
        counter++;
    }
    <div class="row">
        <div class="col">
            <input name="Products[@counter].Name" value="" class="form-control" />
        </div>
        <div class="col">
            <input name="Products[@counter].Category" class="form-control" />
        </div>
        <div class="col">
            <input name="Products[@counter].Price" class="form-control" />
        </div>
    </div>
    <div class="row">
        <div class="col text-center m-1">
            <button class="btn btn-sm btn-danger" type="submit">Save</button>
            <a class="btn btn-sm btn-secondary" asp-action="Index">Cancel</a>
        </div>
    </div>
</form>

当通过导航属性更新数据时,必须注意在 HTML 表单中包含所有现有对象。例如,当 Entity Framework Core 接收到Product对象的集合时,它假设它已经收到了完整的集合,并将尝试打破与集合中不存在的任何Product对象的关系。因此,您必须包括所有现有数据的表单数据值,以及新建元素,以便用户能够输入新的元素,这就是我在清单15-26中所做的。

为了将新视图集成到应用程序中,我将清单15-27所示的元素添加到 Views/Suppliers 文件夹中的 Index.cshtml 视图中。

清单 15-27:Views/Suppliers 文件夹下的 Index.cshtml 文件,使用分部视图

@model IEnumerable<DataApp.Models.Supplier>

@{
    ViewData["Title"] = "Suppliers";
    Layout = "_Layout";
}

@foreach (Supplier s in Model)
{
    <h4 class="bg-info text-center text-white p-1">
        @s.Name
        <a asp-action="Edit" asp-route-id="@s.Id"
           class="btn btn-sm btn-warning">Edit</a>
        <a asp-action="Create" asp-route-id="@s.Id"
           class="btn btn-sm btn-danger">Add</a>
    </h4>
    <div class="container-fluid">
        @if (s.Products == null || s.Products.Count() == 0)
        {
            <div class="p-1 text-center">No Products</div>
        }
        else if (ViewBag.SupplierEditId == s.Id)
        {
            @Html.Partial("Editor", s);
        }
        else
        {
            @foreach (Product p in s.Products)
            {
                <div class="row p-1">
                    <div class="col">@p.Name</div>
                    <div class="col">@p.Category</div>
                    <div class="col">@p.Price</div>
                </div>
            }
            if (ViewBag.SupplierCreateId == s.Id)
            {
                @Html.Partial("Create", s);
            }
        }
    </div>
}

【Add】按钮发送一个请求给名为Create的 action,当ViewBag包含一个SupplierCreateId属性时,将显示 Create.cshtml 视图中的内容,该属性的值与正在处理的SupplierId属性相对应。为了支持新的视图特性,我将清单15-28所示的 action 方法添加到Suppliers控制器中。

清单 15-28:Controllers 文件夹下的 SuppliersController.cshtml 文件,添加 action

using DataApp.Models;
using Microsoft.AspNetCore.Mvc;

namespace DataApp.Controllers
{
    public class SuppliersController : Controller
    {
        private ISupplierRepository supplierRepository;

        public SuppliersController(ISupplierRepository supplierRepo)
        {
            supplierRepository = supplierRepo;
        }

        public IActionResult Index()
        {
            ViewBag.SupplierEditId = TempData["SupplierEditId"];
            ViewBag.SupplierCreateId = TempData["SupplierCreateId"];
            return View(supplierRepository.GetAll());
        }

        public IActionResult Edit(long id)
        {
            TempData["SupplierEditId"] = id;
            return RedirectToAction(nameof(Index));
        }

        [HttpPost]
        public IActionResult Update(Supplier supplier)
        {
            supplierRepository.Update(supplier);
            return RedirectToAction(nameof(Index));
        }

        public IActionResult Create(long id)
        {
            TempData["SupplierCreateId"] = id;
            return RedirectToAction(nameof(Index));
        }
    }
}

Create方法设置ViewBag属性,用于显示 Create.cshtml 视图,然后重定向浏览器。注意,我不必添加 action 方法来处理新建操作。就 Entity Framework Core 而言,此操作是对现有供应商对象的更新,通过Update方法执行。

要查看效果,请重新启动应用程序,导航到 http://localhost:5000/suppliers,并单击其中一个供应商的【Add】按钮。填写表单并单击【Save】,您将看到已经创建了一个新的产品对象,如图15-10所示。

图15-10 新建关联数据

更改关系

由于Product对象与Supplier关联,使得创建和更新操作变得简单。在更改与Product对象关联的Supplier时,需要做更多的工作,特别是对于必要的关系,因为数据库服务器不会执行影响数据库完整性的更新。

为了增加对更改关系的支持,我在 Views/Suppliers 文件夹中添加了一个名为 RelationshipEditor.cshtml 的视图,并添加了清单15-29中所示的内容。

清单 15-29:Views/Suppliers 文件夹下的 RelationshipEditor.cshtml 文件的内容

@model ValueTuple<Supplier, IEnumerable<Supplier>>
@{
    int counter = 0;
}

<form asp-action="Change" method="post">
    <input type="hidden" name="Id" value="@Model.Item1.Id" />
    <input type="hidden" name="Name" value="@Model.Item1.Name" />
    <input type="hidden" name="City" value="@Model.Item1.City" />
    <input type="hidden" name="State" value="@Model.Item1.State" />
    @foreach (Product p in Model.Item1.Products)
    {
        <input type="hidden" name="Products[@counter].Id" value="@p.Id" />
        <input type="hidden" name="Products[@counter].Name" value="@p.Name" />
        <input type="hidden" name="Products[@counter].Category"
                value="@p.Category" />
        <input type="hidden" name="Products[@counter].Price" value="@p.Price" />
        <div class="row">
            <div class="col">@p.Name</div>
            <div class="col">@p.Category</div>
            <div class="col">
                <select name="Products[@counter].SupplierId">
                    @foreach (Supplier s in Model.Item2)
                    {
                        if (p.Supplier == s)
                        {
                            <option selected value="@s.Id">@s.Name</option>
                        }
                        else
                        {
                            <option value="@s.Id">@s.Name</option>
                        }
                    }
                </select>
            </div>
        </div>
        counter++;
    }
    <div class="row">
        <div class="col text-center m-1">
            <button class="btn btn-sm btn-danger" type="submit">Save</button>
            <a class="btn btn-sm btn-secondary" asp-action="Index">Cancel</a>
        </div>
    </div>
</form>

此视图的模型是包含Supplier对象和Suppliers枚举的元组。这是一种笨拙的方法,但正如您将看到的那样,整个过程可能会很尴尬,接收两个数据对象作为视图模型可以更容易地生成允许用户进行更改的 HTML 元素。视图生成具有隐藏元素的表单,这些元素包含不需要更改的SupplierProduct值,以及允许为每个Product选择不同Supplierselect元素。该表单的目标是一个名为Change的 action 方法,我将在稍后定义该方法。

为了将新的分部视图合并到应用程序中,我将清单15-30中所示的元素添加到 Views/Suppliers 文件夹中的 Index.cshtml 视图中。

清单 15-30:Views/Suppliers 文件夹下的 Index.cshtml 文件,合并分部视图

@model IEnumerable<DataApp.Models.Supplier>

@{
    ViewData["Title"] = "Suppliers";
    Layout = "_Layout";
}

@foreach (Supplier s in Model)
{
    <h4 class="bg-info text-center text-white p-1">
        @s.Name
        <a asp-action="Edit" asp-route-id="@s.Id"
           class="btn btn-sm btn-warning">Edit</a>
        <a asp-action="Create" asp-route-id="@s.Id"
           class="btn btn-sm btn-danger">Add</a>
        <a asp-action="Change" asp-route-id="@s.Id"
           class="btn btn-sm btn-primary">Change</a>
    </h4>
    <div class="container-fluid">
        @if (s.Products == null || s.Products.Count() == 0)
        {
            <div class="p-1 text-center">No Products</div>
        }
        else if (ViewBag.SupplierEditId == s.Id)
        {
            @await Html.PartialAsync("Editor", s);
        }
        else if (ViewBag.SupplierRelationshipId == s.Id)
        {
            @Html.Partial("RelationshipEditor", (s, Model));
        }
        else
        {
            @foreach (Product p in s.Products)
            {
                <div class="row p-1">
                    <div class="col">@p.Name</div>
                    <div class="col">@p.Category</div>
                    <div class="col">@p.Price</div>
                </div>
            }
            if (ViewBag.SupplierCreateId == s.Id)
            {
                @Html.Partial("Create", s);
            }
        }
    </div>
}

新元素添加了一个【Change】按钮,该按钮针对控制器上的Change action,该按钮将设置一个名为SupplierRelationshipIdViewBag属性,用于决定何时显示清单15-29中创建的分部视图。为了添加视图所依赖的 action,我将清单15-31中所示的方法添加到SuppliersController类中。

清单 15-31:Controllers 文件夹下的 SuppliersController.cs 文件,添加 action

using DataApp.Models;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.Linq;

namespace DataApp.Controllers
{
    public class SuppliersController : Controller
    {
        private ISupplierRepository supplierRepository;
        public SuppliersController(ISupplierRepository supplierRepo)
        {
            supplierRepository = supplierRepo;
        }
        public IActionResult Index()
        {
            ViewBag.SupplierEditId = TempData["SupplierEditId"];
            ViewBag.SupplierCreateId = TempData["SupplierCreateId"];
            ViewBag.SupplierRelationshipId = TempData["SupplierRelationshipId"];
            return View(supplierRepository.GetAll());
        }
        public IActionResult Edit(long id)
        {
            TempData["SupplierEditId"] = id;
            return RedirectToAction(nameof(Index));
        }
        [HttpPost]
        public IActionResult Update(Supplier supplier)
        {
            supplierRepository.Update(supplier);
            return RedirectToAction(nameof(Index));
        }
        public IActionResult Create(long id)
        {
            TempData["SupplierCreateId"] = id;
            return RedirectToAction(nameof(Index));
        }
        public IActionResult Change(long id)
        {
            TempData["SupplierRelationshipId"] = id;
            return RedirectToAction(nameof(Index));
        }
        [HttpPost]
        public IActionResult Change(Supplier supplier)
        {
            IEnumerable<Product> changed = supplier.Products
            .Where(p => p.SupplierId != supplier.Id);
            if (changed.Count() > 0)
            {
                IEnumerable<Supplier> allSuppliers
                    = supplierRepository.GetAll().ToArray();
                Supplier currentSupplier
                    = allSuppliers.First(s => s.Id == supplier.Id);
                foreach (Product p in changed)
                {
                    Supplier newSupplier
                        = allSuppliers.First(s => s.Id == p.SupplierId);
                    newSupplier.Products = newSupplier.Products
                        .Append(currentSupplier.Products
                            .First(op => op.Id == p.Id)).ToArray();
                    supplierRepository.Update(newSupplier);
                }
            }
            return RedirectToAction(nameof(Index));
        }
    }
}

接受 POST 请求的Change方法中的代码是复杂的,因为功能之间存在一些冲突。我已经查询了数据库,以获得完整的Supplier对象及其关联的Product对象集,以便我可以更新用户已更改的关系。Entity Framework Core 跟踪它创建的对象,这意味着如果不生成异常,我就不能使用 MVC 模型绑定创建的对象执行数据库更新。这意味着我必须处理 MVC 模型绑定创建的对象,以确定需要更改什么,然后将这些更改转换为 Entity Framework Core 已经创建的对象,这些对象可用于更新数据库。(不要花太多时间关注清单15-31中的代码,因为我在下面的部分中展示了更简单的方法。)

要查看如何更改关系,请重新启动应用程序,导航到 http://localhost:5000/suppliers,然后单击其中一个【Change】按钮。使用下拉列表更改关系,并单击【Save】按钮更新数据库。您编辑的Product对象的关系将与其新Supplier一起显示,如图15-11所示。

图15-11 更改对象间的关系

简化关系更改代码

处理清单15-31中的更新所需的代码依赖于一系列 LINQ 查询,以从数据库获取数据,并将来自 HTTP 请求创建的对象的更改合并。这种方法围绕 Entity Framework Core 对象跟踪系统进行工作,该系统支持更改跟踪和修复等功能。这些都是有用的特性,但是跟踪系统阻碍了其他操作。

我可以通过禁用对象跟踪功能来简化处理更改所需的代码,如清单15-32所示,这意味着我可以使用 MVC 模型绑定创建的对象来更新数据库。

清单 15-32:Controllers 文件夹中的 SuppliersController.cs 文件,禁用更改跟踪

using DataApp.Models;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;

namespace DataApp.Controllers
{
    public class SuppliersController : Controller
    {
        private ISupplierRepository supplierRepository;
        private EFDatabaseContext dbContext;
        public SuppliersController(ISupplierRepository supplierRepo,
        EFDatabaseContext context)
        {
            supplierRepository = supplierRepo;
            dbContext = context;
        }
        public IActionResult Index()
        {
            ViewBag.SupplierEditId = TempData["SupplierEditId"];
            ViewBag.SupplierCreateId = TempData["SupplierCreateId"];
            ViewBag.SupplierRelationshipId = TempData["SupplierRelationshipId"];
            return View(supplierRepository.GetAll());
        }
        public IActionResult Edit(long id)
        {
            TempData["SupplierEditId"] = id;
            return RedirectToAction(nameof(Index));
        }
        [HttpPost]
        public IActionResult Update(Supplier supplier)
        {
            supplierRepository.Update(supplier);
            return RedirectToAction(nameof(Index));
        }
        public IActionResult Create(long id)
        {
            TempData["SupplierCreateId"] = id;
            return RedirectToAction(nameof(Index));
        }
        public IActionResult Change(long id)
        {
            TempData["SupplierRelationshipId"] = id;
            return RedirectToAction(nameof(Index));
        }
        [HttpPost]
        public IActionResult Change(Supplier supplier)
        {
            IEnumerable<Product> changed
                = supplier.Products.Where(p => p.SupplierId != supplier.Id);
            IEnumerable<long> targetSupplierIds
                = changed.Select(p => p.SupplierId).Distinct();
            if (changed.Count() > 0)
            {
                IEnumerable<Supplier> targetSuppliers = dbContext.Suppliers
                    .Where(s => targetSupplierIds.Contains(s.Id))
                    .AsNoTracking().ToArray();
                foreach (Product p in changed)
                {
                    Supplier newSupplier
                        = targetSuppliers.First(s => s.Id == p.SupplierId);
                    newSupplier.Products = newSupplier.Products == null
                        ? new Product[] { p }
                            : newSupplier.Products.Append(p).ToArray();
                }
                dbContext.Suppliers.UpdateRange(targetSuppliers);
                dbContext.SaveChanges();
            }
            return RedirectToAction(nameof(Index));
        }
    }
}

为了简单起见,我直接使用了数据 context 类,而不是通过存储库接口和实现类处理更改。清单15-33中的代码更简单(尽管它看起来可能不像),因为通过在查询中使用AsNoTracking扩展方法从数据库检索Supplier和关联Product对象,跟踪功能已经被禁用。

...
IEnumerable<Supplier> targetSuppliers = dbContext.Suppliers
    .Where(s => targetSupplierIds.Contains(s.Id))
    .AsNoTracking().ToArray();
...

当使用AsNoTracking方法时,Entity Framework Core 不跟踪它创建的对象,这允许我使用 MVC 模型绑定器创建的Product对象来更新数据库。

清单15-32中的代码比清单15-31简单的原因之一是,为了执行更新,我不必检索与每个Supplier关联的Product对象。Entity Framework Core 不更新未从数据库查询的数据,因此将Product对象排除在查询之外意味着现有关系不受用户指定的更改的影响。

警告:应该谨慎地使用AsNoTracking方法,因为它会阻止其他有用的功能,如更改检测和修复等。

进一步简化关系更改代码

前两个清单已经表明,可以从一对多关系的“一”端来改变关系,但这样做是很尴尬的。有一种解决这个问题的更简单的方法,可以通过考虑如何在数据库中表示关系来理解这个问题。

在示例应用程序中,Product类具有一个SupplierId属性,用于存储与其关联的供应商的Id属性的值。Supplier类定义的Products属性仅用于方便导航,并且当产品的关系发生更改时,Entity Framework Core 只需更新 Products 表中的行以反映更改,即使您通过IEnumerable<Product>导航属性执行这些更改。

一旦您意识到如何执行更新,就可以通过直接对Product对象进行操作来大大简化执行更新所需的代码,如清单15-33所示。

清单 15-33:Controllers 文件夹下的 SuppliersController.cs 文件,执行直接更新

...
[HttpPost]
public IActionResult Change(long Id, Product[] products)
{
    dbContext.Products.UpdateRange(products.Where(p => p.SupplierId != Id));
    dbContext.SaveChanges();
    return RedirectToAction(nameof(Index));
}
...

更改 action 方法的参数告诉 MVC 模型绑定程序,我需要用户更改其关系的供应商的Id值以及与该供应商相关的产品集。

我使用 LINQ 筛选出那些未更改的Product对象,并将那些必须更改的对象传递给数据库 context 类提供的DbSet<T>.UpdateRange方法,该方法允许我一次更新多个对象。我调用SaveChanges方法将更改发送到数据库,然后将浏览器重定向到Index action。

结果与清单15-31和清单15-32中的代码相同,但更简单、更容易理解。如本例所示,虽然可以在一对多的关系中使用任一导航属性来执行更新,但考虑如何将更新反映到数据库中,可以洞察是否可以通过优先考虑一个属性而不是另一个属性来获得更简单的结果。

总结

在本章中,我演示了如何通过使用 context 属性或使用接受类型参数的 context 方法直接访问关联数据。一旦您提升了数据,通常会希望从它导航到数据模型的其他部分,我向您展示了如何通过添加导航属性来定义一对多的关系,这是 ASP.NET Core MVC 和 Entity Framework Core 开发中最常见的关系。我解释了在一对多关系中查询关联数据的不同方法,以及如何通过一对多导航属性创建和更新关联数据。在结束本章时,我展示了通过导航属性编辑关系是可能的,但是可以通过考虑如何在数据库中表示关系来获得更简单的结果。在下一章中,我将继续演示使用关系的 Entity Framework Core 特性。

;

© 2018 - IOT小分队文章发布系统 v0.3